Shadowsocks Redirect Attack
最近复现了一个比较老的洞,因为涉及到密码学相关的攻击,刚好前段时间也在学习通讯协议相关的知识,于是就比较感兴趣。
先总体概括一下,漏洞成因是因为 Shadowsocks 的作者默认使用了一个不合适的密码组件(使用者是可以自己再重新指定的),导致中间人可以利用 Shadowsocks 的服务端将解密后的流量随意重定向(这样中间人就能看到解密后的流量了)。不过涉及中间人对流量的劫持、篡改、重放,因此在实践操作中利用起来还是比较困难的。
转发流程
不论是什么代理工具,总的流程都是一致的,
在整个框架中,有这么几个角色,分别是用户、目标网站、代理服务器、代理软件。
然后是这么个场景,用户想访问目标网站,但是由于某些限制用户不能直接访问目标网站,不过用户可以访问代理服务器,而代理服务器可以访问目标网站,于是用户则借用代理软件通过代理服务器来访问目标网站的资源。
那么用户具体是如何通过代理软件访问到目标网站上的资源呢?首先用户在本地使用代理软件的客户端,在代理服务器上安装使用代理软件的服务端。当用户像请求目标网站时,会设置代理,那么流量就会先经过客户端,客户端里再将流量转发到服务端上,服务端接收到流量后再将流量转发到目标网站上。目标网站收到请求后给出回应到服务端,服务端再将流量转发到客户端,客户端再将流量转发给用户。
那么存在几个问题,服务端是如何知道用户想要访问的目标网站?客户端传输到服务端的流量是明文传输么?客户端是怎么知道服务端在哪儿的?
那么针对 Shadowsocks,笔者暂时可以给出这样的回答,客户端在对用户的流量包进行封装的时候,会在最前方加入用户的想访问的目标网站的信息,并且,客户端到服务端的流量都是加密的,而相应的配置用户需要提前在客户端和服务端进行配置,包括服务端所在的ip,端口,服务端和客户端加密使用的密钥,加密使用的密码体制。
因此我们可以大致画出这样的一个流程图
1 |
|
由于客户端肯定是在用户本地,并且请求和返回肯定是在一次连接内,服务端并不需要对消息进行额外的封装(服务端也不知道用户的地址信息),只进行加密
1 |
|
那么作为中间人,正常来说我们是没法知道用户的请求内容和网站的返回内容,因为我们并不知道代理软件所用的加密密钥,也就没法解密流量。
但是,我们是否有机会在不解密密文的情况下控制明文呢?废话少说,先抓个包看看具体结构再说。
环境搭建
抓包之前当然是要搭建环境了,本次我们分析的是python版本,所以先下载源码 https://github.com/shadowsocks/shadowsocks/tree/master
然后为了方便,使用下列配置在本地同时开启了客户端和服务端,
1 | { |
然后在自己的vps上的8000端口开启了web服务作为目标网站
1 | clinet: 127.0.0.1 1081 |
抓包分析
尝试使用代理访问vps上的flag文件
1 | import requests |
wireshak开启抓包,过滤规则为:tcp.flags.push == 1 && (tcp.port == 1081 || tcp.port == 8388)
运行脚本得到
捕获流量如下
根据info我们大致可以判断,
用户(脚本)起了一个1872端口,16578-16584这四个包应该是脚本在和客户端(1081端口)进行socket握手,
随后用户向客户端发起了 GET 请求。随后客户端起了一个端口 1873 向服务端(8388端口)发送了流量,根据长度可以看到是多了23个字节,应该是封装+加密,
随后(这一部分由于监听网卡的原因在这里没有捕获到)服务端会解密然后向目标网站发送请求,目标网站回复后,服务端进行加密,
(16597包)服务端向客户端发送加密流量
最后客户端进行解密再向用户发送明文,可以看到解密后长度少了16字节(熟悉分组密码的话大概可以猜到会是少了16字节的iv向量)
源码分析
客户端
在了解到这样一个大致流程之后,我们根据每一个步骤,找到相应的源码,尝试进行更细节的分析。
首先是和客户端的socket的握手,总共有两次请求和两次应答,分别是
客户端第一次请求,格式为 版本号+方法占用字节+方法,这里我们是 05 01 00
服务端第一次回复,格式为 版本号+方法,这里我们是 05 00 ,00 说明 服务端连接无需经过验证
客户端第二次请求,格式为 版本号+CMD+保留字段 RSV+目标地址类型 ATYP+ 目标地址 DST.ADDR + 目标端口 DST.PORT,这里我们是
05 01 00 01 31 eb 75 ef 1f 40,
版本号是5,01是建立连接,00默认,01说明是IPV4地址类型,0x31,0xeb,0x75,0xef 是 ip 各个端的十六进制,0x1f40 说明是8000端口
服务端第二次回复,格式为 版本号+回复字段 REP+保留字段 PSV+目标地址类型 ATYP+ 服务器绑定地址 BND.ADDR + 服务器绑定端口 BND.PORT,这里我们是 05 00 00 01 00 00 00 00 10 10
版本号是5,00 表示连接成功,默认保留字段00 ,01 说明是IPV4地址,00 00 00 00 说明绑定地址是0.0.0.0,0x1010 绑定的端口是4112(为啥是这个嘞)
那么定位相应源码,首先是local.py:main,会将 tcp_server 加入 loop,随后loop.run
1 | def main(): |
定位到eventloop.py:run,会循环获取pool中的事件,获取句柄调用其handle_event方法,
1 | def run(self): |
这里由于我们发起了tcp连接,所以我们定位tcprelay.py
看到TCPRelay的初始化方法
1 | class TCPRelay(object): |
会起一个 server_socket 开启监听(等待用户连接)
当用户连接后,就是我们前面说的,会触发其event_handle方法
1 | def handle_event(self, sock, fd, event): |
此时 sock 是 self._server_socket,一切正常的情况下,就会初始化一个 TCPRelayHandler() 对象,跟过去看他的init方法
几个重要的点,_stage 初始化为 STAGE_INIT,客户端与用户的连接(local_sock)加入了循环,并且将自身与local_sock绑定
1 | self._stage = STAGE_INIT |
然后就又回到loop.run,这一次调用的handler就是local_sock绑定的这个handler(也就是TCPRelayHandler)的handler_event
1 | def handle_event(self, sock, event): |
此时,会进入到 self._local_sock 分支,从用户那里读取第一次的握手消息,由于我们的 self._stage = STAGE_INIT
1 | def _on_local_read(self): |
显然,这里处理的就是用户第一次请求和客户端第一次应答,然后 self._stage = STAGE_ADDR
那么再一次获取到用户的请求后,则执行
1 | elif (is_local and self._stage == STAGE_ADDR) or \ |
由于这里要根据各种指令以不同方式进行解析,所以重新封装了一个方法,这里我们的CMD是00,也就是请求连接
1 | ... |
parse_header是解析地址的方法,具体具体地址类型(ipv4,ipv6,域名)来进行读取
1 | def parse_header(data): |
之后进入 is_local分支
1 | if self._is_local: |
于是就知道为什么根据数据包里捕获到客户端的回复中,绑定的ip和端口是0.0.0.0:4112了,原来这里直接写死了,并没有根据实际情况进行返回。(这里也许可以改进一下)
(这里之所以要把地址信息(也就是data)加密后放进 self._data_to_write_to_remote.append(data_to_send),其实就已经在开始封装接下来要发送的消息了)
然后应该要和服务端进行连接了,看到_dns_resolver.resolve
1 | def resolve(self, hostname, callback): |
这里我们的hostname是ip,所以执行回调函数,也就是 _handle_dns_resolved,比较核心的就是
1 | remote_sock.connect((remote_addr, remote_port)) |
那么loop里有一个新的连接了。正常来说下一步应该是接受用户的数据,然后加密,然后发送给服务端了。此时TCPRelayHandler会再次进入 self._on_local_read(),
1 | elif self._stage == STAGE_CONNECTING: |
接收到信息后 根据 _stage 进入 _handle_stage_connecting
1 | def _handle_stage_connecting(self, data): |
那么由于这里还是满足 _is_local(表示这是客户端),所以信息会先加密,但由于config并没有配置 fast_open,所以直接返回了,根据调试发现,随后loop里有了一个新的event,值是4,也就是表示eventloop.POLL_OUT,于是进入方法 _on_remote_write()
1 | def _on_remote_write(self): |
改变了 self._stage = STAGE_STREAM,然后将 _data_to_write_to_remote 数组里面的值全部发送给了服务端
此时,_data_to_write_to_remote 里的值为 encrypt(ATYPE+IP+PORT+DATA)
我们简单看一下加密方法,首先会载入配置文件设定的密码和加密方法,
1 | self._encryptor = encrypt.Encryptor(config['password'], |
那么这里就是将消息按照指定的方法进行加密,然后拼接上iv,将数据返回。于是最终用户向客户端发送的数据,在客户端发送给服务端时则封装为
iv+encrypt(atype|ip|port|data)
其中,由于我们选择的是默认的aes-256-cfb方法,加密后不会填充,于是封装后的消息长度等于 16+7 + len(data),这就与我们之前抓到的数据包的长度变化吻合了。
然后就是服务端的接收、向目标网站发送请求、对客户端进行回复,
收到服务端的消息后我们进入分支
1 | if sock == self._remote_sock: |
那么客户端会先将消息解密,随后发送给用户。
至此,我们对客户端这里的处理逻辑分析清楚了。
- 首先用户向客户端发起第一次socket握手
- 客户端进行第一次回复
- 用户向客户端发送目的地址的相关信息
- 客户端保存相关信息并加密,放入待发送消息队列;和服务端建立连接;然后对用户进行第二次回复
- 用户向客户端发送对目的地址的相关请求
- 客户端对消息进行加密,放入待发送消息队列,然后将整个消息队列发送给服务端
下面是笔者在审计代码时加入的一些额外的注释,可以更方便的看清整个流程
1 | INFO: loading config from config.json |
服务端
接下来我们分析服务端的处理逻辑,服务端做的事情可以划分为:
- 接受客户端的消息并解密
- 前七个字节是目标网站的信息,于是服务端向目标网站发起连接,然后向目标网站发送解密后七个字节之后的消息
- 接受目标网站的回复
- 将回复加密后发送给客户端
在代码层面和客户端其实没有很大差别,主要调用的也是TCPrelay这一块的代码,区别就是在于标志符 is_local,主要区别我们注意到 _on_local_read 这个函数
1 | def _on_local_read(self): |
我们知道,服务端第一次收到客户端消息的时候,消息内容是 iv+encrypt(atype|ip|port|data),于是,第一步肯定是对其进行解密
1 | if not is_local: |
解密后得到 atype|ip|port|data,此时 self._stage == STAGE_INIT(因为是第一次接收到消息),因此进入 self._handle_stage_addr(data) 函数
1 | def _handle_stage_addr(self, data): |
由于此时是服务端,因此直接进入 parse_header 函数进行解析,得到 addrtype, remote_addr, remote_port, header_length,那么根据前面的经验,接下来就是服务端与目标网站建立连接,随后将用户的消息(data)发送给目标网站,再接收回复,再加密,再发送给客户端。
1 | def _on_remote_read(self): |
于是我们就从代码层面完成了对整个代理流程的分析,那么,问题出现在哪儿呢?
漏洞成因
我们注意到,客户端会将消息解密并发送给用户,而服务端会将消息解密发送给指定的地址,而这个指定的地址则是解密后通过parse_header 解析得到。那么如果我们能够在无法解密的情况下对这个地址进行操控,我们是否就能让服务端将解密后的信息发送到任意我们指定的地址,我们也就能够获得解密后的信息了。
那么如何 在无法解密的情况下对这个地址进行操控 呢?注意到我们选择的默认加密模式为 aes-256-cfb
可以看到是类似于流密码,而我们知道,由于异或运算的特性,流密码是无法抵抗已知明文攻击的,那么我们知道哪些明文呢?
显然,服务端最终发送给客户端的加密消息格式为 IV + encrypt(data) 其中,IV是16个字节,剩下的data,由于HTTP响应包的格式基本上就是HTTP/1.1 200 OK\r\nHost
,因此,我们是能够得到图中的 $e_k$ 的,也就是 IV 经过 AES 加密后的值。我们设明文为 $x_1$,密文为 $y_1$,想要将数据篡改为 $x_2$,于是
由 $y_1 \oplus e_k = x_1$,其中 $x_1,e_k,y_1$ 均已知,因此
$y_1 \oplus e_k \oplus x_1 \oplus x_2 = x_1 \oplus x_1 \oplus x_2 = x_2$
所以我们只需要将密文 $y_1 $ 篡改为 $y_1 \oplus x_1 \oplus x_2$ 即可
由于控制地址部分是七个字节(Atype +ip +port )于是,我们设待破解密文为 IV + encrypt(“HTTP/1.” )+ encrypt(data),
那么我们重新构造密文为 $IV+encrypt(“HTTP/1.” ) \oplus “HTTP/1.” \oplus (Atype+ip+port) + padding(9字节) +IV + encrypt(“HTTP/1.” )+ encrypt(data)$
然后将该数据发送给服务端,服务端收到解密后,将得到消息
$(Atype+ip+port) + (9字节) +(16字节) + data$
前面七个字节是我们控制的地址,然后是9个字节填充解密后的乱码,然后是16个字节IV解密后的乱码,剩下来就是对数据的正常解密。
然后服务端就会与我们控制的地址建立连接,将后续包括解密填充的乱码、解密IV的乱码、解密后的正常数据全部发送过去。那么至此我们就完成了解密数据的重定向,换句话说,我们在没有解密密钥的情况下,获得了数据明文。
漏洞演示
我们抓下服务端最后发送给客户端的加密消息
然后在我自己的服务器上开了一个9999端口进行监听
运行 python 脚本,
1 | from Crypto.Util.number import * |
在服务器9999端口成功收到解密后的消息(可以看到在正常消息前会有一小段乱码)
踩坑记录
在复现的时候踩到一个坑,由于我们是使用python脚本和服务端进行直接通信的,我们在运行完最后一行send后,不能立刻结束脚本,否则脚本和服务端的连接就会断开,而一旦脚本和服务端的连接断开了,服务端也会立刻终止与目标网站的通信(在向目标网站发送解密消息之前)。
1 | def destroy(self): |
因此我们需要让脚本再阻塞一会,等待消息被解密发送完成后再结束脚本,也就是脚本最后一行 time.sleep() 的用意。
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可联系QQ 643713081,也可以邮件至 643713081@qq.com
文章标题:Shadowsocks Redirect Attack
文章字数:6.7k
本文作者:Van1sh
发布时间:2023-01-30, 17:14:00
最后更新:2023-03-07, 18:35:16
原始链接:http://jayxv.github.io/2023/01/30/Shadowsocks Redirect Attack/版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。